Mooncake NDS Integration
本文以 Mooncake main 的提交 468fbf63 为代码截面。文中的 NDS 指内部提供的 NPU Direct Storage 接口,不把它假定为某个公开标准或 NVIDIA 产品。
先找到真正的 GDS 路径
Mooncake 中能搜到两套与 GDS 相关的实现。它们都调用 cuFile,却不处于同一代架构,也不具有同样的接入价值。
旧路径:NVMeoFTransport
经典 Transfer Engine 中的 NVMeoFTransport 通过 USE_NVMEOF 构建,协议名是 nvmeof。它管理 cuFile buffer、文件 handle、Batch ID 和异步事件,测试也能直接调用 allocateBatchID → submitTransfer → getTransferStatus 完成读写。
问题在于,它没有真正接入通用 MultiTransport 批次。当前 submitTransferTask 会直接返回 NotImplemented;对应测试绕过通用调度层,直接持有 Transport* 调用专用接口,参见 nvmeof_transport_test.cpp。
这条路径仍可用来理解 cuFile 的注册、文件 handle 与事件映射,但不适合作为新 NDS 接入的主模板。照着它复制,容易得到一个只能被专用测试调用、无法被当前运行时自动选择的孤立后端。
新路径:TENT GdsTransport
TENT 是 Mooncake 新一代 Transfer Engine。构建时设置 -DUSE_TENT=ON,既可以使用原生 API,也可以通过 MC_USE_TENT=1 让经典 TE 接口委托给 TENT,见 TENT C++ API。
TENT 的 GDS 路径更完整:
- 构建发现:启用 CUDA 且找到
cufile库和头文件时定义USE_GDS。 - 运行时装载:配置
transports/gds/enable后,TransportLoader创建GdsTransport。 - 统一文件段:
file://path被解析为FileSegmentDesc,transport 再按 Segment ID 取回路径。 - 策略选择:默认文件策略按
GDS → IOURING排序;GDS 不可用时可以回退到 CPU staging。 - 批次生命周期:
allocateSubBatch复用昂贵的 cuFile batch handle,submitTransferTasks把大请求切成最多 16 MiB 的 slice,getTransferStatus轮询并聚合每个 slice 的完成状态。 - 失败隔离:取消是 best effort;只要底层仍可能引用参数或用户 buffer,失败批次就不能立即复用,而是进入 quarantine,等所有 I/O 真正终态后再回收。
- 内存注册:CUDA buffer 通过
cuFileBufRegister/cuFileBufDeregister加入或移出 GDS 能力集合。
核心提交与状态处理集中在 gds_transport.cpp。这里最值得复用的不是 cuFileBatchIOSubmit 这一个调用,而是它周围那一整套 注册、切片、提交、轮询、取消、终态确认和资源回收契约。
NDS 应该接在哪里
下图把当前 GDS、目标 NDS 和 Store 的实际磁盘路径放在同一张图里。上半部分是 NDS 第一阶段应该进入的 TENT 文件数据面;下半部分说明为什么新增 NdsTransport 后,Store 仍不会自动走 NDS。

图:蓝色是当前 GDS,紫色是目标 NDS 与第二阶段接线,绿色是 Store 目前绕过 TENT 的文件读写路径。
由此可以把目标拆成两个互不冒充的里程碑:
- TENT 可用:普通 Transfer Engine 请求能够在 NPU buffer 与
file://Segment 之间走 NDS,并具备回退、状态与错误处理。 - Store 可用:
LOCAL_DISK副本的读写显式改走 TENT/NDS,且对象的分配、可见性、校验与故障恢复语义仍成立。
第一项是 transport 接入,第二项是存储后端重构。它们应分两个 PR 或至少两个可独立回滚的提交完成。
第一步:钉死 NDS 契约
不要一开始就在 NdsTransport 里散落 SDK 调用。先加一层很薄的 NdsBackend,把 Mooncake 需要的语义与内部 SDK 的具体名字隔开。
1 | enum class NdsDirection { kReadFromFile, kWriteToFile }; |
在适配层中再完成方向映射:
1 | Request::WRITE → NDS send → NPU HBM 写入文件 |
编码前至少确认以下问题,否则 transport 的生命周期无法定型:
send/receive接受路径、文件描述符,还是 SDK 自己的文件 handle;- NPU buffer 是否必须预注册,注册粒度和注销约束是什么;
- 文件 offset、设备地址和长度分别要求怎样的对齐;
- 提交返回的是 request、event、队列槽位,还是同步状态;
- 一次请求是否支持多个 slice,最大 batch depth 是多少;
cancel是否存在,已入队请求能否保证不再访问用户 buffer;- SDK 是否线程安全,完成队列由谁推进,进程退出时如何 drain。
第二步:让运行时认识 NPU
这是当前代码里最容易漏掉、也最先应该修的地方。
TENT 已经在 policy schema 中接受 local_memory: "npu",但 AscendPlatform::getMemoryType 返回的仍是 MTYPE_CUDA。TransportSelector 又把 MTYPE_CUDA 转成字符串 cuda,因此写出 local_memory: "npu" 并不会真的命中 Ascend NPU buffer。
应先完成三项修改:
- 在
MemoryType中新增MTYPE_NPU,并让 Ascend platform 返回它。 - 在
matchesMemoryPattern和设备内存判断中加入MTYPE_NPU。 - 把文件能力从含混的
gpu_to_file拆出npu_to_file,避免 GDS 和 NDS 因共享一个布尔位而被错误互选。
最小改动是继续把 NPU 当作 gpu_to_file,再靠 policy 把 GDS/NDS 分开;但这会把正确性压在配置顺序上。长期可维护的实现应在类型和 capability 两层都区分 CUDA 与 NPU。
同时要修复回退 transport。当前 IOUringTransport 只有看到 MTYPE_CUDA 才分配 CPU staging buffer。加入 MTYPE_NPU 后,应把这类判断统一成 isDeviceMemory(type),否则 NDS 不可用时会把 NPU 地址误当成普通 CPU 指针交给 io_uring。
第三步:加入 NDS transport
扩展公开枚举
在固定提交 468fbf63 中,TransportType 依次到 MPCOMM,随后是 sentinel。为了不改变已有 transport 的数值,应把 NDS 追加在 kNumTransportTypes 之前,而不是插进 GDS 附近。
当前截面下需要同步更新:
tent/include/tent/common/types.h:TransportType::NDS、transportTypeName、parseTransportType;tent/include/tent/transfer_engine.h:C API 宏TRANSPORT_NDS,在该提交上对应数值13;tent/src/python/pybind.cpp:暴露TransportType.NDS,补充必要的数值一致性断言;- 所有以
kSupportedTransportTypes为长度的数组会随 sentinel 自动扩容,但仍需跑覆盖测试。
这里的 13 只对固定提交成立。如果上游同时新增 transport,应以合并后的枚举顺序重新生成并核对,而不是永久硬编码这篇文章里的数字。
实现生命周期
建议新增:
1 | tent/include/tent/transport/nds/nds_transport.h |
NdsTransport 至少实现以下接口:
| TENT 接口 | NDS 责任 |
|---|---|
install/uninstall |
初始化 SDK、读取 queue depth、停止接收新任务并 drain 完成队列 |
addMemoryBuffer/removeMemoryBuffer |
注册或注销 NPU HBM,并把 NDS 写入 buffer 的 transports |
allocateSubBatch/freeSubBatch |
分配请求槽位;保存 SDK request、slice 范围和稳定的参数存储 |
submitTransferTasks |
解析 File Segment,将 READ/WRITE 映射为 receive/send,按 SDK 上限和对齐切片 |
getTransferStatus |
轮询 completion,聚合字节数,保留第一个终态错误 |
cancelTransferTask |
best effort 取消;未确认终态前不得释放 SDK 仍可能访问的 buffer 或参数 |
capabilities |
声明 npu_to_file=true,不要伪装成 CUDA GDS |
GDS 固定使用 16 MiB slice 是 cuFile 当前实现的选择,不要原样复制成 NDS 常数。NDS 应从 SDK 限制、NPU 页粒度、NVMe 队列深度和真实 KV block 分布确定切片大小,并允许通过配置调节。
错误路径也应复制语义,而不是复制代码:某个 slice 失败后可以请求取消兄弟 slice,但 task 只有在所有底层 I/O 都不再触碰用户内存时才能公布终态并释放 batch。否则上层可能在 DMA 尚未停止时复用 KV buffer。
接入构建与装载
构建层建议增加:
1 | -DUSE_NDS=ON |
CMake 应在 NDS_ROOT 下寻找头文件和库,找到后定义 USE_NDS、链接 SDK,并把 tent_xport_nds 加入 TENT transport 集合。运行时在 TransportLoader 中按以下配置创建实例:
1 | { |
io_batch_depth 只是配置形态示例,默认值必须根据 NDS SDK 的真实队列约束确定,不能因为 GDS 当前默认 32 就直接照搬。
修正选择与回退
当前默认文件策略没有 memory filter,候选顺序固定为 GDS → IOURING。接入 NDS 后,不应简单改成 NDS → GDS → IOURING,因为那会让正确性依赖 capability 检查是否足够严密。
更清晰的策略是按本地内存类型分三条规则:
1 | { |
预期结果必须是:NPU 优先 NDS、CUDA 优先 GDS、CPU 走 io_uring;NDS 或 GDS 缺失时,设备内存先经过受控的 CPU staging 再落盘。
复用文件段,必要时再扩元数据
如果 NDS 接受 path 或普通 fd,可以直接复用 file://path 与现有 FileBufferDesc{path,length,offset},不用创造 nds://。这能让 GDS、NDS 和 io_uring 共享同一个文件 Segment,只由 selector 决定执行后端。
如果 NDS 还需要 namespace、设备队列、存储 endpoint 或专用文件句柄,就应为 FileBufferDesc 增加类似 Memory Segment 的 transport_attrs,按 transport 保存序列化属性。不要把 NDS 私有字段直接塞进通用结构顶层,否则每加入一个存储后端都会扩大公共 schema。
第四步:单独接通 Store
新增 NdsTransport 后,Transfer Engine 的文件请求已经可用,但 Mooncake Store 的 LOCAL_DISK 副本仍不会自动经过它。
当前读路径是:
1 | submitFileReadOperation |
源码证据分别见 submitFileReadOperation 与 StorageBackend::LoadObject。这条链路直接在 Store worker 中执行文件 I/O,没有创建 file:// Segment,也没有提交 TENT batch。
先改读路径
读比写更适合作为 Store 集成的第一刀:文件、对象 offset 和目标 NPU slice 都已经确定,不需要先改变对象可见性。
可以增加一个 TransferEngineFileOperationState:
- 根据 disk replica 的文件路径创建或缓存
file://pathSegment; - 为每个目标 NPU slice 构造
Request::READ; target_offset指向对象值区在文件中的真实偏移;- 提交 TENT batch,并把轮询结果转换为现有
TransferFuture; - NDS 不可用时按 policy 回退到 io_uring staging;
- 保留现有 file worker 作为可配置回退,直到 NDS 路径通过故障注入。
对于 OffsetAllocatorStorageBackend,不能直接从 record 起点把整个记录读进 NPU。它的格式是 header + key + padding + value,只有 value 区按 4 KiB 对齐。安全做法是继续在 CPU 侧读取并校验 header/key,再用 NDS receive 只搬 value 区。
写路径需要 reserve/commit
写路径不能只把 vector_write 换成 send。当前 BatchOffload 在同一个函数中完成空间分配、header/key/CRC 构造、磁盘写入和内存索引发布;它没有暴露给外部 DMA writer 使用的 reserve/commit 边界。
建议把它拆成三段:
1 | prepare_record |
任何失败都必须释放尚未发布的 allocation,且不能让 Master 看见一个未完成的 LOCAL_DISK 副本。当前 record layout 已经明确为未来 GDS/DMA writer 保留了 4 KiB 对齐,并用 seq 防止恢复时接受 checkpoint 之后的 torn write,见 storage_backend.h。
CRC 是第二个边界。当前默认 CRC-32C 覆盖 header prefix、key 和 value;如果 value 从未经过 CPU,就无法沿用现有 CPU 计算方式。可选方案只有三类:
- SDK 或 NPU 侧同时计算 CRC,再把结果写回 header;
- 为 direct write 清除
kFlagHasCrc,接受恢复时少一层 torn/stale 检测; - 额外把 value 拉回 CPU 计算 CRC,但这会抵消 direct storage 的主要意义。
在没有设备侧校验能力时,第二种与现有格式兼容,但必须把可靠性降级写进配置、指标与故障测试,不能静默发生。
修改顺序
为了让每一步都能独立验证,建议按以下顺序提交:
- SDK 契约测试:实现 fake
NdsBackend,固定 send/receive、offset、alignment、completion 与 cancel 语义。 - NPU 类型修复:增加
MTYPE_NPU、npu_to_file和通用isDeviceMemory;先让 selector 测试全部通过。 - 公共枚举与绑定:追加
TransportType::NDS,更新 C API、Python、名称解析和数组边界测试。 - TENT transport:实现注册、文件 context、sub-batch、切片、提交、轮询、取消与 quarantine。
- 构建和配置:接入
USE_NDS/NDS_ROOT、loader、默认配置与自定义 policy。 - TENT 端到端:用临时文件验证 NPU→文件、文件→NPU、非对齐尾部、partial failure、取消与回退。
- Store 只读接入:新增 TENT file operation,先覆盖普通 file replica,再覆盖 OffsetAllocator 的 value offset。
- Store 写入重构:引入 prepare/send/commit,处理 CRC、失败回滚、淘汰与恢复。
- 性能验收:在真实 KV block 分布上比较 NDS、io_uring staging 与现有 Store worker,而不是只测单个大块峰值带宽。
对应的测试矩阵至少应覆盖:
| 层次 | 必测行为 |
|---|---|
| Selector | NPU→NDS、CUDA→GDS、CPU→io_uring、NDS 缺失时安全回退 |
| Buffer | 注册/注销、重复注册、越界、未注册内存、进程退出清理 |
| File | path/fd 生命周期、offset 与 length 对齐、文件截断、权限错误 |
| Batch | 多 slice、乱序完成、部分失败、取消后 drain、状态字节数单调 |
| Store read | header/key 校验、value 直读 NPU、淘汰期间 extent pin |
| Store write | 完成前不可见、失败释放 extent、重启恢复、CRC 开关语义 |
| 性能 | 小块时延、聚合吞吐、CPU 占用、队列深度、注册成本和 P99 |
最终判断
现在可以确认的是:Mooncake 当前最完整的 GPU 直存储接入点是 TENT GdsTransport,而不是经典 NVMeoFTransport;它提供的真正模板是 transport 生命周期与运行时选择。也可以确认,Store 的文件副本当前由自己的 worker 和 StorageBackend 读写,因此仅新增 NDS transport 不足以打通 NPU KV Cache 到 SSD 的端到端路径。
证据还不能支持的是 NDS 的真实方向、对齐、取消、注册与完成语义。没有 SDK 头文件和最小示例,就不应把伪接口直接写成生产代码。
所以最实际的下一步不是一次性改 Store,而是先拿真实 NDS API 完成 NdsBackend 契约表和 fake test。只要这层钉死,TENT 接入就有清晰模板;待普通 file:// 读写稳定后,再把 Store 的 read 与 write 分开推进,系统边界会清楚得多。